SPB Git

spb/autoscholar

Public
4.0 KB · 119 lines tsx
1"use client";2import { useEffect, useRef, useState } from "react";3import { fmtDateTime } from "@/lib/format";45type Ev = {6  seq: number;7  at: string;8  level: string;9  kind: string;10  stage?: string | null;11  message: string;12  data?: unknown;13};1415export function LiveTimeline({ runId, initial, active }: { runId: string; initial: Ev[]; active: boolean }) {16  const [events, setEvents] = useState<Ev[]>(initial);17  const [state, setState] = useState<"idle" | "live" | "reconnecting" | "closed">(18    active ? "reconnecting" : "closed",19  );20  const lastSeq = useRef(initial.at(-1)?.seq ?? 0);21  const bottom = useRef<HTMLDivElement>(null);22  const [follow, setFollow] = useState(true);23  useEffect(() => {24    if (!active) return;25    let es: EventSource | null = null;26    let stopped = false;27    let retry = 1000;28    const connect = () => {29      if (stopped) return;30      es = new EventSource(`/api/admin/runs/${runId}/events?stream=1&after=${lastSeq.current}`);31      es.addEventListener("hello", () => {32        setState("live");33        retry = 1000;34      });35      es.addEventListener("run.event", (m) => {36        const ev = JSON.parse((m as MessageEvent).data) as Ev;37        if (ev.seq <= lastSeq.current) return;38        lastSeq.current = ev.seq;39        setEvents((x) => [...x, ev]);40        if (ev.kind === "status.transition" && /→ (published|failed|cancelled)/.test(ev.message)) {41          stopped = true;42          es?.close();43          setState("closed");44          setTimeout(() => window.location.reload(), 1500);45        }46      });47      es.onerror = () => {48        es?.close();49        setState("reconnecting");50        if (!stopped) {51          retry = Math.min(retry * 2, 15000);52          setTimeout(connect, retry);53        }54      };55    };56    connect();57    return () => {58      stopped = true;59      es?.close();60    };61  }, [runId, active]);62  useEffect(() => {63    if (follow) bottom.current?.scrollIntoView({ block: "nearest" });64  }, [follow]);65  return (66    <div className="surface mt-2">67      <div className="flex items-center justify-between border-b px-3 py-2 text-xs hairline">68        <span aria-live="polite">69          {state === "live" ? (70            <span style={{ color: "var(--color-ok-500)" }}>● live</span>71          ) : state === "reconnecting" ? (72            <span style={{ color: "var(--color-warn-500)" }}>● reconnecting…</span>73          ) : (74            <span className="subtle">● stream closed</span>75          )}{" "}76          · {events.length} events · cursor {lastSeq.current}77        </span>78        <label className="flex items-center gap-1">79          <input type="checkbox" checked={follow} onChange={(e) => setFollow(e.target.checked)} /> follow80        </label>81      </div>82      <ol83        className="max-h-[32rem] overflow-auto p-2 font-mono text-xs leading-relaxed"84        aria-label="Run events"85      >86        {events.length === 0 && <li className="muted p-2">No events yet.</li>}87        {events.map((e) => (88          <li89            key={e.seq}90            className="grid grid-cols-[3rem_9rem_7rem_1fr] gap-2 border-b py-1 hairline"91            style={{92              color:93                e.level === "error"94                  ? "var(--color-danger-500)"95                  : e.level === "warn"96                    ? "var(--color-warn-500)"97                    : undefined,98            }}99          >100            <span className="subtle">{e.seq}</span>101            <span className="subtle">{fmtDateTime(e.at)}</span>102            <span className="subtle truncate">{e.stage ?? ""}</span>103            <span>104              <span className="font-semibold">{e.kind}</span> {e.message}105              {e.data && Object.keys(e.data as object).length > 0 ? (106                <details className="inline">107                  <summary className="inline cursor-pointer subtle"> ⋯</summary>108                  <pre className="whitespace-pre-wrap">{JSON.stringify(e.data, null, 1)}</pre>109                </details>110              ) : null}111            </span>112          </li>113        ))}114        <div ref={bottom} />115      </ol>116    </div>117  );118}119
Files · main
Menu

Theme